THE CUDA C++ DEVELOPER'S TOOLBOX

Bryce Adelstein Lelbach, NVIDIA

目录

引言:CPU 与 GPU 架构差异

讲座首先对比了中央处理器(CPU)和图形处理器(GPU)的核心架构及其内存系统特性,以阐明两者在设计哲学和适用场景上的根本不同。

CPU与GPU核心架构对比 - Page 2
CPU与GPU核心架构对比 - Page 2
CPU与GPU及其内存系统性能对比 - Page 4
CPU与GPU及其内存系统性能对比 - Page 4

在 C++ 中进行 GPU 编程

为了利用 GPU 的强大并行计算能力,开发者可以在 C++ 环境中通过以下两种主要方式进行编程:

  1. CUDA C++
  2. 调用加速库

CUDA C++ 概述

CUDA C++ 是标准 C++(Standard C++)的一个扩展,旨在支持编写能够同时在 CPU(主机,Host)和 GPU(设备,Device)上异构执行的程序。

CUDA C++ 主机与设备代码模型 - Page 7
CUDA C++ 主机与设备代码模型 - Page 7

NVIDIA 加速库生态系统

除了直接使用 CUDA C++ 编写底层代码,NVIDIA 还提供了一个丰富且功能强大的加速库生态系统。这些库针对特定领域进行了深度优化,使开发者能够轻松地在其应用程序中集成 GPU 加速功能。该生态系统包括但不限于:

NVIDIA CUDA 加速库生态系统 - Page 8
NVIDIA CUDA 加速库生态系统 - Page 8

从 C++ 到 CUDA C++ 的演进

编程范例:热量传递模拟

为了具体说明编程概念,讲座以一个简单的热量传递模拟为例。

1. 问题设定

模拟三个不同初始温度的杯子在特定环境温度下的冷却过程。
- 初始温度分别为:42°C, 24°C, 50°C。

2. 物理模型

温度的更新遵循牛顿冷却定律的简化模型。下一时刻的温度由当前温度、环境温度和传热系数共同决定。

根据此模型,经过一个时间步长后,三个杯子的温度将分别从 42°C, 24°C, 50°C 更新为 31°C, 22°C, 35°C。

热量传递模型与计算示例 - Page 10
热量传递模型与计算示例 - Page 10

3. 标准 C++ 实现

该模拟过程可以使用标准 C++ 代码实现。核心是定义一个更新操作,并在一个循环中对每个杯子的温度应用该操作。

#include <vector>
#include <numeric>   // For std::views::iota
#include <algorithm> // For std::transform
#include <print>     // For std::println (C++23)

int main() {
    int steps = 3;
    float k = 0.5f;
    float ambient_temp = 20.0f;
    std::vector<float> cups{42.0f, 24.0f, 50.0f};

    // 定义单步温度更新操作 (lambda 函数)
    auto op = [=](float t) {
      float diff = ambient_temp - t;
      return t + k * diff;
    };

    // 模拟多个时间步长
    for (int step : std::views::iota(0, steps)) {
      std::println("Step {}: {}", step, cups);
      // 对容器中的每个元素应用更新操作
      std::transform(cups.begin(), cups.end(),
                     cups.begin(), op);
    }
    std::println("Final: {}", cups);
}

代码中的 std::transform 算法是关键,它遍历输入范围(cups 容器),对每个元素应用 op 函数,并将结果写回目标位置。这是一个典型的可以被并行化的数据并行操作。

std::transform 操作示意图 - Page 14
std::transform 操作示意图 - Page 14

代码演进与编译流程

1. 标准 C++ 编译与执行

上述标准 C++ 代码通过 GCC (g++) 等传统 C++ 编译器进行编译。编译器将高级的 C++ 表达式(如 t + k * diff)翻译成 CPU 可以直接执行的底层机器指令(例如 ARM 架构下的 vmla.f32 指令)。最终生成一个在主机 CPU 上运行的可执行文件。

标准C++代码的编译过程 - Page 15
标准C++代码的编译过程 - Page 15

2. CUDA C++ 编译流程

在CUDA C++中,源代码通过NVIDIA C++编译器(NVCC)进行编译。NVCC能够区分主机(Host)代码和设备(Device)代码。它将这两种代码分离开来,分别编译成适用于CPU和GPU的可执行部分。

编译过程如下图所示,nvcc main.cpp -o a.out 命令会将一份CUDA C++源代码(例如 main.cpp)编译成两个部分:一部分是CPU可执行的指令(如vmla.f32),另一部分是GPU可执行的指令(如fma.rm.f32)。

CUDA C++ 编译流程图,展示了源代码通过NVCC编译分离成主机代码和设备代码。
CUDA C++ 编译流程图,展示了源代码通过NVCC编译分离成主机代码和设备代码。

执行空间说明符與主机-设备执行模型

执行空间说明符 (Execution Space Specifiers)

为了让GPU能够执行计算,我们需要告诉编译器哪些代码可以在设备上运行。这通过在函数或lambda表达式前添加执行空间说明符来实现。

在热量模拟的例子中,我们为lambda表达式 op 添加了 __host__ __device__ 说明符,表示它可以同时被主机和设备编译和调用。

auto op = [=] __host__ __device__ (float t) {
    float diff = ambient_temp - t;
    return t + k * diff;
};
函数执行空间说明符的解释
函数执行空间说明符的解释

主机-设备执行模型

CUDA的编程模型明确区分了主机(CPU)和设备(GPU)的执行流程。

  1. 执行始于主机:程序的 main 函数在CPU上启动。
    执行从主机CPU开始

  2. 显式启动设备任务:主机代码通过特定的调用(例如内核启动语法 <<<...>>> 或调用并行库函数)来显式地在GPU上启动计算任务。
    主机显式地在设备上启动工作

  3. 主机与设备间的转换是显式的:代码执行从CPU到GPU的切换必须由程序员明确指定。
    主机和设备之间的转换是显式的

  4. 设备上调用的函数停留在设备上:一旦执行流程转移到GPU,在设备上调用的 __device__ 函数会继续在GPU上执行,直到该设备任务完成。
    在设备上调用的函数会继续在设备上执行

使用 Thrust 库实现并行化

标准库算法(如std::transform)通常在主机上串行执行。为了在GPU上并行执行这些操作,我们可以使用CUDA生态系统中的并行算法库,例如 Thrust

只需将 std::transform 替换为 thrust::transform,并提供一个执行策略(如thrust::cuda::par,代表在CUDA设备上并行执行),即可将计算任务调度到GPU上。

同时,std::vector 的内存分配在主机端,GPU通常无法直接访问。为了解决数据可访问性问题,Thrust 提供了 thrust::universal_vector。它使用统一内存(Unified Memory),使得数据对于主机和设备都是可见和可访问的。

// 完整的 CUDA C++ 并行化代码
int steps = 3;
float k = 0.5;
float ambient_temp = 20;
thrust::universal_vector<float> cups{42, 24, 50}; // 修改:使用 universal_vector

auto op = [=] __host__ __device__ (float t) { // 修改:添加执行空间说明符
    float diff = ambient_temp - t;
    return t + k * diff;
};

for (int step : std::views::iota(0, steps))
{
    std::print("{} {}
", step, cups);
    thrust::transform(thrust::cuda::par, cups.begin(), cups.end(), // 修改:使用 thrust::transform
                      cups.begin(), op);
}

Thrust:C++并行算法库

Thrust是一个基于C++标准库(STL)的CUDA C++并行算法库。它提供了丰富的高性能并行算法接口,使开发者可以轻松编写GPU加速代码。

Thrust库主要包含以下几个核心组件:

1. 算法(Algorithms)

Thrust提供了一系列与C++标准库兼容的算法,以及一些为并行计算设计的扩展算法。

Page 31
Page 31

2. 容器(Containers)

Thrust提供了类似于STL容器的并行数据结构,用于管理主机(CPU)和设备(GPU)内存。

Page 32
Page 32

thrust::universal_vectorstd::vector相比具有显著优势。std::vector的内容通常只能在主机代码中访问,并且其构造和赋值操作是串行执行的。相比之下,thrust::universal_vector的内容可以在主机和设备代码中无缝访问,并且其构造和赋值操作是并行执行的,从而提高了性能。

Page 34
Page 34

3. 迭代器(Iterators)

迭代器是Thrust库的核心抽象,它将算法与容器解耦。除了常规的指针式迭代器,Thrust还提供了一系列特殊的迭代器适配器,用于创建复杂的数据序列而无需显式地在内存中存储它们。

Page 35
Page 35

使用Thrust迭代器实现高级并行模式

Thrust的迭代器适配器是实现高效、可组合的并行代码的关键。

虚拟数据序列

通过使用特殊的迭代器,可以在不占用额外内存的情况下生成输入序列。

算法融合(Algorithm Fusion)

一个常见的并行模式是transform后紧跟一个reduce操作。传统实现方式需要一个临时向量来存储transform的中间结果,这会消耗宝贵的设备内存,并且在两个内核调用之间引入了不必要的同步点和内存流量。

通过使用thrust::transform_iterator,可以将transformreduce操作融合成一个单一的内核调用。transform_iteratorreduce算法访问数据时,即时(on-the-fly)地应用转换函数,从而避免了临时内存的分配和中间数据的读写。

// 低效的实现方式:需要临时存储 tmp
thrust::universal_vector X(N), tmp(N);
thrust::transform(thrust::cuda::par, X.begin(), X.end(), tmp.begin(), f);
auto r = thrust::reduce(thrust::cuda::par, tmp.begin(), tmp.end(), T{}, g);
Page 41
Page 41
// 高效的融合实现:使用 transform_iterator 避免临时存储
thrust::universal_vector X(N);
auto tmp_iterator = thrust::make_transform_iterator(X.begin(), f);
auto r = thrust::reduce(thrust::cuda::par, tmp_iterator, tmp_iterator + N, T{}, g);

这个版本的代码将两个操作逻辑上融合在一起,提高了执行效率和内存利用率。

Page 42
Page 42

处理多数据流

thrust::zip_iterator能够将多个数据序列“压缩”在一起,使算法可以对每个位置上来自不同序列的元素进行操作。例如,可以将向量XY的元素配对成(X[i], Y[i])的元组流。

Page 43
Page 43

zip_iterator也非常适用于实现模板(stencil)操作,例如计算相邻元素的差分。通过将一个迭代器与其自身偏移一个位置后的迭代器进行压缩,可以方便地访问 X[i]X[i+1]

Page 44
Page 44

Thrust 算法优化示例:计算最大差值

本节通过一个计算两个向量AB对应元素之差的最大值的例子,展示了如何使用 Thrust 库逐步优化代码,以减少内存占用和提高性能。

初始状态
首先,我们有两个 thrust::universal_vector<int> 类型的向量 AB

Page 46
Page 46

步骤 1: transform + reduce (使用临时存储)
第一种实现方法是分两步进行,需要分配一个额外的向量 diffs 来存储中间结果,这会增加内存开销。

Page 48
Page 48

步骤 2: 使用迭代器适配器 (Iterator Adaptors)
为了避免创建临时向量,我们可以使用 thrust::make_zip_iteratorthrust::make_transform_iterator。这种方法通过"迭代器融合"(iterator fusion)避免了中间存储,从而减少了内存占用并可能提高性能。

Page 49
Page 49

步骤 3: 使用 transform_reduce (算法融合)
最高效的方法是使用 thrust::transform_reduce 算法。这个算法将转换(transformation)和归约(reduction)两个操作合并成一个单一的内核调用。这种"算法融合"(algorithm fusion)是最高效的方式,代码更简洁,并且性能通常是最好的。

Page 52
Page 52

libcu++: CUDA C++ 基础库

libcu++ 是 CUDA C++ 的基础库,提供了可在主机(Host)和设备(Device)代码中使用的标准 C++ 功能。

libcu++ 扩展了标准的 C++ 库,使其能够在异构计算环境中使用。其命名空间和头文件结构如下:

  1. 主机编译器标准库 (Host Compiler's Standard Library)

    • 头文件: #include <...>
    • 命名空间: std::
    • 描述: 标准 C++ 库,仅限于在 __host__ 代码中使用。
  2. libcu++ - 标准 C++ 子集

    • 头文件: #include <cuda/std/...>
    • 命名空间: cuda::std::
    • 描述: 提供了标准 C++ 的一个子集,可在 __host____device__ 代码中安全使用。
  3. libcu++ - CUDA C++ 扩展

    • 头文件: #include <cuda/...>
    • 命名空间: cuda::
    • 描述: 提供了可在 __host__ 和/或 __device__ 代码中使用的现代 C++ API。
  4. libcu++ - 实验性功能

    • 头文件: #include <cuda/experimental/...>
    • 命名空间: cuda::experimental:: (或 cudax::)
    • 描述: 包含尚在开发中的 Beta 功能。
Page 58
Page 58

高级应用:热方程求解器

模板计算 (Stencil Computation)

该图展示了一个典型的模板(Stencil)计算模式。模板计算是科学计算和图像处理中常见的并行计算模式。新网格中的每个点都依赖于旧网格中的一个局部区域。这种数据依赖模式非常适合在 GPU 上进行并行化。

Page 60
Page 60

使用 Thrust 实现与 mdspan 重构

本节通过一个热方程求解器的例子,展示了如何使用 Thrust 库编写并行计算代码,并引入了 C++ 标准库中的 mdspan 来优化和简化多维数据处理。

1. 原始实现:手动索引计算

最初的实现使用 thrust::for_each_n 并行处理每个网格点。在 Lambda 函数内部,需要手动将一维索引 xy 转换回二维坐标 xy,并使用 x*ny + y 这样的方式来访问数据。

Page 70: 重构前的代码
Page 70: 重构前的代码

2. mdspan 简介与代码重构

mdspan 是一个非拥有的(non-owning)多维数据句柄,它提供了一个多维数组的视图,但本身不管理内存。使用 cuda::std::mdspan 重构后的代码,有以下优点:

Page 71: 使用 mdspan 重构后的 heat_equation 代码
Page 71: 使用 mdspan 重构后的 heat_equation 代码

3. mdspan 的高级用法:submdspan

mdspan 还支持创建子视图(subspan),这对于处理边界条件等场景非常有用。例如,在初始化函数 initialize_oven 中,可以使用 cuda::std::submdspan 从原始 mdspan 中创建代表顶部、中部和底部区域的视图,而无需复制数据。

void initialize_oven(auto policy, cuda::std::mdspan<float, cuda::std::dims<2>> U) {
    auto nx = U.extent(0);
    auto top = cuda::std::submdspan(U, 0, cuda::std::full_extent);
    auto mid = cuda::std::submdspan(U, std::tuple{1, nx-2}, cuda::std::full_extent);
    auto bot = cuda::std::submdspan(U, nx-1, cuda::std::full_extent);

    thrust::fill_n(policy, top.data(), top.size(), 90.0);
    thrust::fill_n(policy, mid.data(), mid.size(), 15.0);
    thrust::fill_n(policy, bot.data(), bot.size(), 90.0);
}

这种方法使得对多维数据特定区域的操作变得非常简洁和高效。

Page 76
Page 76

异步执行与性能优化

同步与异步执行模型

Page 84
Page 84
Page 87
Page 87

CUDA 流 (Stream) 与事件

为了提升性能并实现更精细的控制,可以采用 CUDA 流(Stream)。一个流是设备上按顺序执行的一系列操作。

Page 92 的代码与图示
Page 92 的代码与图示

通过将全局同步 cudaDeviceSynchronize() 替换为针对特定流的 stream.wait(),可以实现更高效的异步执行,避免不必要的全局阻塞。

// 同步版本
for (auto write_step : std::views::iota(0, write_steps)) {
    cudaDeviceSynchronize(); // 全局同步点
    save_to_file(U);
    // ...
}

// 异步版本
cudax::stream stream;
auto policy(thrust::cuda::par_nosync.on(stream.get()));
// ...
for (auto write_step : std::views::iota(0, write_steps)) {
    stream.wait(); // 仅等待此流完成
    save_to_file(U);
    // ...
}

Thrust 异步接口与阻塞行为

在使用 par_nosync 异步策略时,并非所有 Thrust 算法都是非阻塞的。
- 非阻塞: 像 for_each_ntransform 这样不返回值或返回可预先计算值的算法,是真正的异步操作。
- 阻塞: 某些 Thrust 算法会阻塞主机线程,因为主机需要等待GPU的计算结果:
- 返回一个依赖于计算结果的值(如 reducecopy_iffind_if)。
- 需要为计算分配临时存储空间(如 inclusive_scan)。

Page 97 说明
Page 97 说明

底层库 CUB 简介

CUB - CUDA C++ 算法创作工具包

CUB 是一个用于创作高性能 CUDA C++ 算法的工具包,为编写 CUDA C++ 内核提供了可组合的构建块。

Page 101 CUB 概览
Page 101 CUB 概览

Thrust 与 CUB API 对比:Reduce 操作

Thrust 提供更高级、更简洁的接口,而 CUB 提供更底层、更灵活的接口。以 reduce 为例,Thrust 一行代码即可完成,但它是一个阻塞操作。

Page 104 Thrust Reduce 实现原理
Page 104 Thrust Reduce 实现原理

使用 CUB 实现相同功能需要更明确的两步:
1. 查询临时存储大小:首次调用 cub::DeviceReduce::Reduce,传入 nullptr 来获取执行所需的临时存储大小。
2. 分配内存并执行归约:分配所需内存后,再次调用 cub::DeviceReduce::Reduce 并传入内存指针以执行计算。

这种模式将资源分配与算法执行解耦,允许开发者对内存进行复用或其他优化,更好地支持异步操作。

Page 109
Page 109

使用 CUB 优化模板计算

对于模板计算,可以使用 cub::DeviceFor::ForEachInExtents。这是一个专为多维数据结构设计的 CUB API。它会自动处理从一维线程索引到多维坐标的映射,消除了在 Lambda 内部手动计算坐标的需要,使代码更简洁、可读,并可能带来性能优势。

Page 114
Page 114

内存管理

thrust::universal_vector 的内存模型

thrust::universal_vector 利用统一内存(Unified Memory),为异构计算提供了统一的内存视图。
- 逻辑视图: 用户看到的是一个普通的向量容器。
- 物理视图与自动迁移: 在底层,它在主机和设备内存中维护数据副本。当数据在一端被修改并在另一端被访问时,系统会自动、按需(通常以页为单位)地将数据迁移到访问端,这个过程对用户是透明的。

Page 119 和 Page 120 展示了对不同数据元素的按需迁移过程。
Page 119 和 Page 120 展示了对不同数据元素的按需迁移过程。

Thrust 容器对比与数据传输

Page 126: Thrust 容器特性对比表。
Page 126: Thrust 容器特性对比表。

隐式传输虽然方便,但可能会导致意外的同步和性能瓶颈。为了精确控制数据传输,可以采用显式传输的策略:使用一个 thrust::host_vector 作为主机端缓冲区,并通过 thrust::copy_n 异步地将数据从 device_vectoruniversal_vector 显式复制到该缓冲区。

Page 129: 使用 host_vector 和显式复制的优化代码。
Page 129: 使用 host_vector 和显式复制的优化代码。

优化后的执行流程

通过使用显式数据复制,可以构建一个更高效的主机与设备之间的工作流程。设备端可以异步地将计算结果复制到主机端缓冲区,而主机则在确保复制完成后再进行文件保存等操作。这避免了在关键计算循环中由隐式内存访问引起的阻塞。

Page 136
Page 137

总结与生态系统

CUDA 软件生态系统

NVIDIA CUDA 提供了一个由众多库、API 和工具组成的庞大生态系统,以支持各种领域的加速计算,包括线性代数、深度学习、数据分析、信号处理等。

Page 138
Page 138

GTC 2025 CUDA 开发者会议

GTC 大会提供了大量与 CUDA C++、CUDA Python、性能优化和多 GPU 编程相关的开发者会议和议题。

Page 139
Page 139

相关链接:
- GitHub: http://github.com/NVIDIA/accelerated-computing-hub
- GTC 开发者页面: http://nvidia.com/gtc/sessions/cuda-developer